iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
AI & Data

AI 營養師 + Web3 數位健康護照系列 第 24

Day24. Flask 與資料庫整合 Ep3:用 TDD(測試驅動開發)+ SQLite 完成AI營養顧問的資料庫功能 Part-1

  • 分享至 

  • xImage
  •  

一、TDD(Test-Driven Development,測試驅動開發)是什麼?

TDD(Test-Driven Development,測試驅動開發)是一種以「先寫測試、後寫程式」為核心的開發方法。傳統開發往往先撰寫功能,再事後補上測試;而在 TDD 中,開發流程被刻意反轉。

先想清楚「期望程式行為」並以測試程式的方式描述它,然後再讓開發程式碼去通過這個測試。這個循環通常簡稱為 Red–Green–Refactor:

  • Red:先撰寫一個會失敗的測試,確認目前功能尚不存在。
  • Green:撰寫最小量的程式碼,使測試通過。
  • Refactor:優化程式結構,保持測試綠燈狀態。

測試流程示意表(紅 → 綠 → 藍)

階段 動作 結果
紅燈 寫完測試、執行 pytest 測試失敗,確認測試邏輯正確
綠燈 新增最小程式碼 所有測試通過
藍燈 重構程式、移除重複 維持通過狀態,程式更乾淨

TDD 的價值不僅在於提升測試覆蓋率,更重要的是它能引導開發者以「需求」為中心思考設計。因為測試事先定義了介面與期望結果,開發者在實作階段能明確知道「什麼是足夠的功能」,也能更容易拆分問題、重構程式,減少未來維護的難度。

以資料庫功能為例,TDD 會先定義出資料的 CRUD 行為測試(例如「新增使用者後能成功查詢出來」),再逐步實現每個資料操作。這種流程讓開發者能及早發現邏輯錯誤、結構不合理或資料設計問題。

二、一般開發 vs 測試驅動開發(TDD)

類型 典型順序 說明
一般開發流程 實作功能 → 撰寫單元測試 → 驗證功能 先把功能寫出來,再補測試
TDD(測試驅動開發)流程 撰寫單元測試 → 實作功能 → 重構 先寫測試,再寫讓測試通過的最小實作

三、TDD 資料庫設計規劃

  • Flask — Web 應用框架
  • Flask-SQLAlchemy — ORM 層
  • pytest — 單元測試框架
  • SQLite — 測試階段使用 in-memory 資料庫

四、設計原則

在 TDD 模式下,每個設計決策都須具備「可測試性」。
以下為本專案設計的三大原則:

  1. 簡潔性(KISS)

    • 將可變結構(例如營養分析細項)以 JSON 字串儲存在欄位 nutrients_json
    • 在初期 POC 階段避免建立過多關聯表,以快速驗證邏輯正確性。
  2. 可測試性(Testability)

    • 測試環境使用 sqlite:///:memory:,確保每個測試函式皆獨立、快速且不干擾其他案例。
    • 以工廠函式或動態建立 app 的方式進行初始化。
  3. 最小可行實作(MVP)

    • 先撰寫測試案例,定義預期行為。
    • 僅在測試失敗(紅燈)時進行最小修改以通過測試(綠燈)。
    • 通過後再進行程式結構優化(重構階段)。

五、資料模型設計構想

目標模型:AnalysisRecord

此模型對應一筆 AI 食物分析結果,包含原始資料、摘要、營養成分等欄位。

欄位名稱 型別 說明
id Integer, Primary Key 唯一識別碼
image_path String(255), Unique, Not Null 圖片路徑(避免重複上傳)
created_at DateTime (timezone-aware) 建立時間
raw_analysis Text, Not Null AI 回傳的原始文字或 JSON
summary Text 食物摘要描述
nutrients_json Text 儲存營養細項(JSON 字串)

此外,會提供對應的 nutrients 屬性
讓開發者可直接以 Python 結構(list/dict)操作,而非手動處理 JSON 字串。


六、本專案具體執行步驟:

  1. 定義測試規格(CRUD 與 JSON 欄位操作)。
  2. 撰寫 tests/conftest.pytests/test_models.py
  3. 執行 pytest,觀察錯誤訊息。
  4. 實作 models.py 內的最小邏輯(nutrients property、timezone-aware 時間欄位)。
  5. 修正過時 API (Model.query.getdb.session.get)。
  6. 反覆測試直到所有測試通過且無警告。

七、測試規格

測試案例需驗證以下行為:

測試項目 驗證內容
建立紀錄 可建立一筆 AnalysisRecord,並寫入 nutrients(Python 結構)
JSON 正確儲存 確認 nutrients_json 內為合法 JSON 字串
查詢行為 能正確讀回同筆紀錄與對應欄位
更新行為 更新 summary 後可正確讀回新值
刪除行為 可刪除該筆紀錄並驗證資料消失
進階測試 created_at 欄位自動生成且為 timezone-aware

八、測試環境設定(tests/conftest.py

此檔案負責建立 Flask 測試應用與測試資料庫。

import pytest
from flask import Flask

try:
    from app import create_app as _create_app
except Exception:
    _create_app = None

try:
    from models import db as _db
except Exception:
    _db = None

@pytest.fixture(scope="function")
def app():
    """建立測試用 Flask App,使用 in-memory SQLite"""
    config = {
        "TESTING": True,
        
        # 使用 `sqlite:///:memory:` 讓測試資料存在記憶體中。
        "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
        "SQLALCHEMY_TRACK_MODIFICATIONS": False,
    }

    if _create_app is not None:
        app = _create_app(config)
    else:
        app = Flask(__name__)
        app.config.update(config)
        if _db is None:
            raise RuntimeError("找不到 models.db,請確認 models.py 有定義 db = SQLAlchemy()")
        _db.init_app(app)

    with app.app_context():
        _db.create_all()
        yield app
        _db.session.remove()
        _db.drop_all()

@pytest.fixture
def db(app):
    """提供資料庫 session 給測試案例"""
    return _db

補充說明

  • 每次測試執行前後皆自動建立與銷毀資料表。

九、主要測試檔案(tests/test_models.py

import json
from models import AnalysisRecord, db

def test_analysisrecord_crud_and_nutrients_json(db):
    """測試 CRUD 與 JSON 欄位行為"""
    rec = AnalysisRecord(
        image_path="uploads/test_apple_pie.jpg",
        raw_analysis="{\"ai\":\"result\"}",
        summary="蘋果派 測試"
    )
    rec.nutrients = [
        {"nutrient": "calories", "value": 250},
        {"nutrient": "fat", "value": 12.5}
    ]

    db.session.add(rec)
    db.session.commit()

    # 查詢
    found = AnalysisRecord.query.filter_by(image_path="uploads/test_apple_pie.jpg").first()
    assert found is not None
    assert found.summary == "蘋果派 測試"
    assert isinstance(found.nutrients, list)
    assert found.nutrients[0]["nutrient"] == "calories"

    # 驗證 JSON 結構
    parsed = json.loads(found.nutrients_json)
    assert parsed[1]["value"] == 12.5

    # 更新
    found.summary = "更新摘要"
    db.session.commit()
    updated = db.session.get(AnalysisRecord, found.id)
    assert updated.summary == "更新摘要"

    # 刪除
    db.session.delete(updated)
    db.session.commit()
    assert db.session.get(AnalysisRecord, found.id) is None

上一篇
Day23. Flask 與資料庫整合 Ep2:練習用 SQLite 實作留言板功能(&單元測試)
下一篇
Day25. Flask 與資料庫整合 Ep4:用 TDD(測試驅動開發)+ SQLite 完成AI營養顧問的資料庫功能 Part-2
系列文
AI 營養師 + Web3 數位健康護照27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言